Skip to content

mcp: keep a long-running POST stream visibly alive - #1197

Open
spkenny455 wants to merge 2 commits into
modelcontextprotocol:mainfrom
spkenny455:fix/post-stream-early-flush
Open

mcp: keep a long-running POST stream visibly alive#1197
spkenny455 wants to merge 2 commits into
modelcontextprotocol:mainfrom
spkenny455:fix/post-stream-early-flush

Conversation

@spkenny455

Copy link
Copy Markdown

The SSE response to a POST writes nothing until the request it carries completes. A tool call that runs for minutes therefore produces no bytes at all in the meantime, not even the response headers, so a client that applies a first-byte or idle timeout cannot tell a working call apart from a dead connection and hangs up on it. Intermediaries that buffer idle responses have the same problem.

The standalone GET stream already handles this (#410) by committing the headers and writing an SSE comment up front. Do the same for streams created by servePOST, after a short delay: committing the headers fixes the HTTP status, and a SEP-2575 protocol-level error must still be able to set its own. Those errors are produced without any I/O, so a stream still silent after the delay is a genuinely long-running call. If the stream has already been flushed, such an error is delivered as an ordinary SSE event instead.

An immediate flush at stream setup is wrong: it breaks TestStreamableStateless_NewProtocolSession_NoFakeInit with a superfluous WriteHeader from deliverLocked.

The delay is hardcoded at 1s (not a StreamableHTTPOptions knob). Happy to change the delay or make it configurable if that's preferred.

Test plan

  • go test ./mcp/ -count=1
  • go test -race ./mcp/ -count=1
  • TestPOSTStreamFlushesHeadersEarly — headers and an SSE comment arrive while the tool is still blocked
  • TestPOSTProtocolErrorKeepsOverrideStatus — in-stream InvalidParams still returns HTTP 400 before the delay
  • TestPOSTUnknownMethodKeeps404 — pre-stream MethodNotFound still returns HTTP 404
  • unit tests for override-before-flush, flush-after-override, cancel, release() reset, JSON buffering, close(), and concurrent flush vs deliverLocked

Fixes #1155

The SSE response to a POST writes nothing until the request it carries
completes. A tool call that runs for minutes therefore produces no bytes
at all in the meantime, not even the response headers, so a client that
applies a first-byte or idle timeout cannot tell a working call apart
from a dead connection and hangs up on it. Intermediaries that buffer
idle responses have the same problem.

The standalone GET stream already handles this (modelcontextprotocol#410) by committing the
headers and writing an SSE comment up front. Do the same for streams
created by servePOST, after a short delay: committing the headers fixes
the HTTP status, and a SEP-2575 protocol-level error must still be able
to set its own. Those errors are produced without any I/O, so a stream
still silent after the delay is a genuinely long-running call. If the
stream has already been flushed, such an error is delivered as an
ordinary SSE event instead, which is the only option once the status is
fixed.

The flush holds the stream mutex, serialising it with deliverLocked and
close (the other writers to s.w) and with release, which clears the
writer and the per-request headersFlushed flag so a resumed stream can
still set an error status.

Fixes modelcontextprotocol#1155
acquireStream only wrote an SSE comment for the standalone GET
(s.id == ""). A Last-Event-ID resume of an in-flight POST with an
empty replay therefore hung with uncommitted headers, the same
first-byte bug as the original POST.

Flush once when claiming a live stream whose headers are still
uncommitted. Also drop the idle-timeout overclaim (the write is
one-shot), take the stream mutex around the priming write, and
record implicit WriteHeader(200) in the test helper.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streamable HTTP: POST response writes nothing until the request completes, so long tool calls look like a dead connection

2 participants